// TODO: CODE REVIEW & CLEANUP!
//
// VSerial.cs
//
// Console application that tests virtual serial port input/output for a DLL that implements
// a class based on HomeUX.Utilities.VirtualSerialPort.
//

using System;
using System.Diagnostics;
using System.IO.Ports;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using HomeUX.Utilities;

class Program : IDisposable
{
    ///////////////////////////////////////////////////////////////////////////////////////////////
	// Private Constants
	//

	/// <summary>
	/// At various times, text is flashed for a short period of time and then disappears; this is
	/// the number of millieconds it appears for.
	/// </summary>
	const int FlashMsec = 750;

	/// <summary>
	/// Matches either a single nonprintable character (in group 1) or a run of
	/// printable characters (in group 2).
	/// </summary>
	static readonly Regex TextRunRegex = new Regex(@"([^\x20-\x7E])|([\x20-\x7E]+)"); 

    ///////////////////////////////////////////////////////////////////////////////////////////////
	// Private Fields
	//

	/// <summary>
	/// A stack of objects that are used while the application is running.  These objects will be
	/// disposed when the application stops.
	/// </summary>
	Disposer m_disposer = new Disposer();

	/// <summary>
	/// The virtual or actual serial port to communicate with, as specified by command-line
	/// arguments.
	/// </summary>
	VirtualSerialPort m_serialPort;

	/// <summary>
	/// <n>true</n> if -log was specified on the command line.  In this case, output is written to
	/// both standard output and standard error streams, so that e.g. "&gt; log.txt" is possible
	/// while still allowing the user to read the output.
	/// </summary>
	bool m_logMode;

	/// <summary>
	/// <n>true</n> if -echo was specified on the command line.  In this case, if we're in
	/// "character mode", characters are echoed locally.
	/// </summary>
	bool m_echoMode = false;

	/// <summary>
	/// Nonzero if -h was specified on the command line.
	/// </summary>
	char m_hexEscape = (char) 0;

    /// <summary>
    /// <n>true</n> if the last character received from the serial port was a carriage return.
    /// In this case, if the next character received is a line feed, we ignore that character.
    /// </summary>
    bool m_lastReceivedCharWasCr;

    /// <summary>
    /// This object is locked while writing to the console, to prevent separate threads from
    /// interfering with each other's output.
    /// </summary>
    object m_consoleLock = new object();

	/// <summary>
	/// Application entry point.
	/// </summary>
	///
	static void Main(string[] args)
	{
		// send debug output to the console, using specific text coloring
		Debug.Listeners.Add(new AppTraceListener());

		Program program = null;
        try
        {
            using (program = new Program(args))
            {
            }
        }
        catch (VirtualSerialPortException ex)
        {
            Console.Error.WriteLine("Error: {0}", ex.Message);
        }
        catch (SpecificUsageException ex)
        {
            Console.Error.WriteLine(ex.Message);
        }
        catch (UsageException)
        {
            Console.Error.WriteLine(
                //123456789.123456789.123456789.123456789.123456789.123456789.123456789.123456789.
                "Usage: VSerial [-cr | -lf | crlf] [-echo] [-h <c>] [-log]\n" +
                "               {-port <portName>,<baud>,<dataBits>,<parity>,<stopBits>,<f> |\n" +
                "                -enumPorts | -dll <construction-spec>]}\n" +
                "\n" +
                "Tests communication with a real or HomeUX virtual serial port.\n" +
                "\n" +
                "By default, typed characters are immediately sent to the serial port.  This\n" +
                "is \"character mode\".\n" +
                "\n" +
                "Alternatively, use \"line mode\" by specifying one of the following options;\n" +
                "in this case, you can edit lines, and they're sent when you press Enter.\n" +
                "  -cr    Uses \"line mode\"; Enter is translated into carriage return (CR).\n" +
                "  -lf    Uses \"line mode\"; Enter is translated into line feed (LF).\n" +
                "  -crlf  Uses \"line mode\"; Enter is translated into CRLF.\n" +
                "\n" +
                "Options that are not valid in \"line mode\":\n" +
                "  -echo  Locally echos typed characters.\n" +
                "  -h <c> Turns on \"hex mode\", using character <c> (e.g. %) as a hexadecimal\n" +
                "         escape; outputs printable bytes as \"<c><hex>`<byte>'\", unprintable\n" +
                "         bytes as \"<c><hex>\"; typing e.g. \"<c>1B\" outputs the ESC\n" +
                "         character; typing \"<c><c>\" outputs <c>.\n" +
                "\n" +
                "Other options:\n" +
                "  -log   Copies output to both standard output and standard error, to allow\n" +
                "         logging using e.g. \"> log.txt\".\n" +
                "  -char  Force \"character mode\" even if a preceding -cr, -lf, or -crlf is\n" +
                "         used.  Useful for virtual serial ports that require a specific line\n" +
                "         terminator even though you want to use \"character mode\".\n" +
                "\n" +
                "After specifying all preceding options, specify the serial port to connect to:\n" +
                "\n" +
                "If -port is specified, a real serial port is opened, using the given port name,\n" +
                "baud rate, data bits setting, parity (E/M/N/O/S), and stop bits setting\n" +
                "(0/1/1.5/2).  The serial parameters may be truncated to use defaults, which\n" +
                "are \"COM1,9600,8,N,1\"; if not truncated, flags may be added to the end:\n" +
                "DtrEnable, RtsEnable.\n" +
                "\n" +
                "Use -enumPorts to enumerate real serial ports.\n" +
                "\n" +
                "If -dll is specified, a virtual serial port is instantiated from a DLL;\n" +
                "<construction-spec> is of the form:\n" +
                "    <dll-path>[;<class-name>[;<constructor-argument>]]\n" +
                "<dll-path> is the absolute or relative (to VSerial.exe) path of the DLL;\n" +
                "<class-name> is the full or base name of the class to instantiate (it must be\n" +
                "based on HomeUX.Utilities.VirtualSerialPort); <constructor-argument>\n" +
                "is passed to the class constructor.  <class-name> defaults to the DLL name\n" +
                "without a path or extension.\n" +
                "\n" +
                "Example: VSerial -cr -dll AspSimulator.dll\n");
        }
#if !DEBUG
        catch (Exception ex)
        {
			lock ((program != null) && (program.m_consoleLock != null) ? program.m_consoleLock :
				new object())
			{
				Console.Error.WriteLine("\nError: {0}", ex.Message);
			}
        }
#endif
	}

	/// <summary>
	/// Implements the application.
	/// </summary>
	///
	Program(string[] args)
	{
		// parse command-line arguments
		ArgumentEnumerator argEnum = new ArgumentEnumerator(args);
		bool lineMode = false; // true if -cr, -lf, or -crlf are specified
		string lineTerminator = "\r\n"; // or "\r" or "\n"; ignored if <lineMode> is false
		SerialParameters serialParameters = null; // null unless -port is specified
		string constructionSpec = null;
		//string className = null;
		while (argEnum.MoveNext() && argEnum.CurrentIsOption())
        {
            if (argEnum.CurrentIsOption("cr"))
			{
				lineMode = true;
				lineTerminator = "\r";
			}
			else
            if (argEnum.CurrentIsOption("lf"))
			{
				lineMode = true;
				lineTerminator = "\n";
			}
			else
            if (argEnum.CurrentIsOption("crlf"))
			{
				lineMode = true;
				lineTerminator = "\r\n";
			}
			else
            if (argEnum.CurrentIsOption("echo"))
				m_echoMode = true;
			else
            if (argEnum.CurrentIsOption("h"))
			{
				if (!argEnum.MoveNext())
					throw new UsageException();
				if (argEnum.Current.Length != 1)
				{
					throw new SpecificUsageException(
						"-h should specify a single escape character, not \"{0}\"",
						argEnum.Current);
				}
				m_hexEscape = argEnum.Current[0];
			}
			else
            if (argEnum.CurrentIsOption("log"))
				m_logMode = true;
			else
            if (argEnum.CurrentIsOption("char"))
				lineMode = false;
			else
            if (argEnum.CurrentIsOption("port"))
			{
				if (!argEnum.MoveNext())
					throw new UsageException();
				try
				{
					serialParameters = SerialParameters.Parse(argEnum.Current);
				}
				catch (FormatException ex)
				{
					throw new SpecificUsageException("-port usage error: {0}", ex.Message);
				}
				break;
			}
			else
            if (argEnum.CurrentIsOption("enumPorts"))
			{
                Console.WriteLine(String.Join(", ", SerialPort.GetPortNames()));
                return;
            }
			else
            if (argEnum.CurrentIsOption("dll"))
			{
                if (!argEnum.MoveNext())
                    throw new UsageException();
                constructionSpec = argEnum.Current;
            }
			else
				throw new UsageException();
        }
		argEnum.NoMoreArgumentsAllowed();

		// check for command line argument consistency
		if (m_echoMode && lineMode)
			throw new SpecificUsageException("-echo is only valid in character mode");
		if ((m_hexEscape > 0) && lineMode)
			throw new SpecificUsageException("-h cannot be specified with -cr, -lf, or -crlf");

        // set <m_serialPort> to be a virtual or actual serial port, as specified by command-line
        // arguments
        if (serialParameters != null)
        {
            // open a real serial port
            try
            {
                m_serialPort = new ActualSerialPort(serialParameters);
            }
            catch (ArgumentException ex)
            {
                throw new SpecificUsageException("-port error: {0}", ex.Message);
            }
            m_disposer.Push(m_serialPort);
        }
        else
        if (constructionSpec != null)
        {
            try
            {
                m_serialPort = Util.CreateInstance<VirtualSerialPort>(constructionSpec);
            }
            catch (CreateInstanceException ex)
            {
                Write(ex.Message + "\n");
                return;
            }
            m_disposer.Push(m_serialPort);
        }
        else
            throw new UsageException();

		// if "character mode" was specified, see if the serial port is compatible with that mode
		if (!lineMode && !m_serialPort.SupportsCharacterBasedTextMode &&
		    !m_serialPort.SupportsBinaryMode)
		{
            string requiredOption = null;
            if (constructionSpec != null)
            {
                switch (m_serialPort.NewLine)
                {
                case "\r": requiredOption = "-cr"; break;
                case "\n": requiredOption = "-lf"; break;
                case "\r\n": requiredOption = "-crlf"; break;
                }
            }
            if (requiredOption != null)
            {
                Write("You must specify {0} with this virtual serial port\n", requiredOption);
                return;
            }
            else
            {
                Write("This serial port implemention supports neither character-based text mode nor binary mode, so you must specify -cr, -lf, or -crlf\n");
                return;
            }
		}

		// if "hex mode" was specified, see if the serial port is compatible with that mode;
		// similar if it wasn't secified
		if (m_hexEscape > 0)
		{
			if (!m_serialPort.SupportsBinaryMode)
			{
				Write("This serial port implemention doesn't support binary mode, so you cannot specify -h\n");
				return;
			}
		}
		else
		{
			if (!m_serialPort.SupportsCharacterBasedTextMode &&
			    !m_serialPort.SupportsLineBasedTextMode)
			{
				Write("This serial port implemention doesn't support text, so you must specify -h\n");
				return;
			}
		}

		// configure the line terminator of the serial port, if applicable
		if (m_serialPort.SupportsLineBasedTextMode)
            m_serialPort.NewLine = lineTerminator;

		// prepare to receive data from the serial port
        m_serialPort.DataReceived += serialPort_DataReceived;

		// open the serial port
		m_serialPort.Open();

		if (lineMode)
		{
			// execute "line mode"...

			// begin reading serial input from the console and directing it to <m_serialPort>;
			// direct output from <m_serialPort> to the console
			lock (m_consoleLock)
			{
				using (Util.ConsoleReverseVideo())
				{
					Console.Error.Write("Serial port open -- type Ctrl+Z & Enter to quit...");
				}
				Console.Error.WriteLine();
			}
			while (true)
			{
				string line = Console.ReadLine();
                if (line == null) // Ctrl+Z
                    break;
                if (m_serialPort.SupportsLineBasedTextMode)
    				m_serialPort.WriteLine(line);
                else
                if (m_serialPort.SupportsCharacterBasedTextMode)
                {
                    m_serialPort.Write(line + lineTerminator);
                }
                else
                if (m_serialPort.SupportsBinaryMode)
                {
                    byte[] bytes = Encoding.ASCII.GetBytes(line + lineTerminator);
                    m_serialPort.Write(bytes, 0, bytes.Length);
                }
			}
		}
		else
		{
			// execute "character mode"...

			// read user data
            WriteCharacterModeBanner();
			bool quit = false;
			byte[] buffer = new byte[1];
            while (!quit)
            {
                ConsoleKeyInfo keyInfo = Console.ReadKey(true);
                if (keyInfo.Key == ConsoleKey.Escape)
                {
					while (true)
					{
						lock (m_consoleLock)
						{
							using (Util.ConsoleReverseVideo())
								Console.Error.Write("\nC=Clear Screen, Q=Quit, Esc=Continue>");
						}
						keyInfo = Console.ReadKey(true);
						if (keyInfo.Key == ConsoleKey.C)
						{
							lock (m_consoleLock)
								Console.Clear();
							break;
						}
						else
						if (keyInfo.Key == ConsoleKey.Q)
						{
							quit = true;
							break;
						}
						else
						if (keyInfo.Key == ConsoleKey.Escape)
						{
							lock (m_consoleLock)
								Console.Error.WriteLine();
							break;
						}
					}
                }
                else
				if (keyInfo.KeyChar == m_hexEscape)
				{
					// the user pressed the hex escape character -- allow them to enter two
					// more hex digits...

					// set <byteToSend> to the byte the user enters
					byte byteToSend;
					lock (m_consoleLock)
					{
						using (Util.ConsoleReverseVideo())
							Console.Error.Write("HEX>");
					}
					int leftDigit, rightDigit;
					keyInfo = Console.ReadKey(true);
                    if (keyInfo.KeyChar == m_hexEscape)
                    {
                        // user entered e.g. "<m_hexEscape><m_hexEscape>" -- change into
                        // "<m_hexEscape>"
                        lock (m_consoleLock)
                            Console.Error.Write("\b\b\b\b    \b\b\b\b");

                    }
                    else
					if ((keyInfo.Key == ConsoleKey.Escape) ||
						!TryParseHexDigit(keyInfo.KeyChar, out leftDigit))
					{
						// cancel this character
						lock (m_consoleLock)
						{
							using (Util.ConsoleReverseVideo())
								Console.Error.Write("\b\b\b\b(CANCEL)");
							Thread.Sleep(FlashMsec);
							Console.Error.Write("\b\b\b\b\b\b\b\b        \b\b\b\b\b\b\b\b");
						}
						continue;
					}
					else
					{
						lock (m_consoleLock)
						{
							using (Util.ConsoleReverseVideo())
								Console.Error.Write("{0:X}", leftDigit);
						}
						keyInfo = Console.ReadKey(true);
						if ((keyInfo.Key == ConsoleKey.Escape) ||
							!TryParseHexDigit(keyInfo.KeyChar, out rightDigit))
						{
							// cancel this character
							lock (m_consoleLock)
							{
								using (Util.ConsoleReverseVideo())
									Console.Error.Write("\b\b\b\b\b(CANCEL)");
								Thread.Sleep(FlashMsec);
								Console.Error.Write("\b\b\b\b\b\b\b\b        \b\b\b\b\b\b\b\b");
							}
							continue;
						}
						else
						{
							// done entering hex digits...

							// briefly display the second digit, then erase "HEX>xx"
							lock (m_consoleLock)
							{
								using (Util.ConsoleReverseVideo())
									Console.Error.Write("{0:X}", rightDigit);
                                if (!m_echoMode) // even more briefly if echo mode is on
    								Thread.Sleep(FlashMsec);
								Console.Error.Write("\b\b\b\b\b\b      \b\b\b\b\b\b");
							}
							
							// set <byteToSend> to the completed byte
							byteToSend = (byte) ((leftDigit << 4) | rightDigit);

							// echo the byte, if -echo was specified on the command line
							if (m_echoMode)
								WriteByteAsHex(byteToSend);

							// write the byte to the serial port
							if (m_serialPort.SupportsBinaryMode)
							{
								buffer[0] = byteToSend;
								m_serialPort.Write(buffer, 0, 1);
							}
							else
							if (m_serialPort.SupportsCharacterBasedTextMode)
								m_serialPort.Write(new String(keyInfo.KeyChar, 1));
						}
					}
				}
                else
                if (keyInfo.Key == ConsoleKey.Enter)
					Transmit(lineTerminator);
				else
					Transmit(keyInfo.KeyChar);
            }
		}
	}

    /// <summary>
    /// Transmits a character to the serial port.  The character is echoed locally if -echo was
    /// specified on the command line.
    /// </summary>
    ///
    /// <param name="ch">The character to transmit.</param>
    ///
	void Transmit(char ch)
	{
		// echo the character, if -echo was specified on the command line
		if (m_echoMode && (ch != '\r'))
			WriteChar(ch);

		// write the character to the serial port
		if (m_serialPort.SupportsCharacterBasedTextMode)
			m_serialPort.Write(new String(ch, 1));
		else
		if (m_serialPort.SupportsBinaryMode)
		{
			byte[] buffer = new byte[1];
			buffer[0] = (byte) ch;
			m_serialPort.Write(buffer, 0, buffer.Length);
		}
	}

    /// <summary>
    /// Transmits a string to the serial port.  The string is echoed locally if -echo was specified
	/// on the command line.
    /// </summary>
    ///
    /// <param name="str">The string to transmit.</param>
    ///
	void Transmit(string str)
	{
		// echo the character, if -echo was specified on the command line
		foreach (char ch in str)
		{
			if (m_echoMode && (ch != '\r'))
				WriteChar(ch);
		}

		// write the string to the serial port
		if (m_serialPort.SupportsCharacterBasedTextMode)
			m_serialPort.Write(str);
		else
		if (m_serialPort.SupportsBinaryMode)
		{
			byte[] buffer = Encoding.ASCII.GetBytes(str);
			m_serialPort.Write(buffer, 0, buffer.Length);
		}
	}

    /// <summary>
    /// Called when data is received from the serial port.
    /// </summary>
	///
    void serialPort_DataReceived(VirtualSerialPort m_serialPort, SerialData eventType)
    {
		// if <eventType> specifies that EOF was received, handle it specially
		if (eventType == SerialData.Eof)
		{
			lock (m_consoleLock)
			{
				using (Util.ConsoleReverseVideo())
				{
					Write("<ReceivedEof>");
					return;
				}
			}
		}

		if (m_hexEscape == 0)
		{
			// text mode...

			// read all existing characters
			string received = m_serialPort.ReadExisting();

			// we attempt to auto-detect CR/LF/CRLF as follows:
			//   -- we convert all CRLF to LF;
			//   -- we convert all CR to LF;
			//   -- special case: if the last character in <received> was CR then we ignore LF if
			//      it's the next character received from the serial port
			if (m_lastReceivedCharWasCr && received.StartsWith("\n"))
				received = received.Substring(1);
			m_lastReceivedCharWasCr = received.EndsWith("\r");
			received = received.Replace("\r\n", "\n").Replace("\r", "\n");

			// write the data received from the serial port using a color that distinguishes it
			// from typed text; also, write unprintable characters in "%<hex-digit><hex-digit>"
			// format, in reverse video
			lock (m_consoleLock)
			{
				using (Util.SetConsoleForegroundColor(ConsoleColor.Yellow))
				{
					foreach (Match match in TextRunRegex.Matches(received))
					{
						string unprintable = match.Groups[1].Value;
						string printable = match.Groups[2].Value;
						if (printable.Length > 0)
							Write(printable);
						else
						if (unprintable == "\n")
							Write("\n");
						else
						{
							using (Util.ConsoleReverseVideo())
								Write("%{0:X2}", (int) unprintable[0]);
						}
					}
				}
			}
		}
		else
		{
			// binary/hex mode...

			// read all existing bytes
			int bytesAvailable = m_serialPort.BytesToRead;
			byte[] buffer = new byte[bytesAvailable];
			int bytesRead = Math.Min(bytesAvailable, m_serialPort.Read(buffer, 0, bytesAvailable));

			// write the data received from the serial port using a color that distinguishes it
			// from typed text
			lock (m_consoleLock)
			{
				using (Util.SetConsoleForegroundColor(ConsoleColor.Yellow))
				{
					for (int i = 0; i < bytesRead; i++)
						WriteByteAsHex(buffer[i]);
				}
			}

		}
    }

    /// <summary>
    /// Displays a banner providing instructions in character mode.
    /// </summary>
	///
    void WriteCharacterModeBanner()
    {
		lock (m_consoleLock)
		{
			using (Util.ConsoleReverseVideo())
				Console.Error.Write("Serial port open -- press Esc for menu...");
			Console.Error.WriteLine();
		}
    }

	/// <summary>
	/// Formats a string and writes it to standard output.  If -log was specified on the command
	/// line the string is also written to standard error, so that e.g. "&gt; log.txt" is possible
	/// while still allowing the user to read the output.
	/// </summary>
	/// 
	/// <param name="format">The format string.  If <pr>args</pr> is empty, <pr>format</pr> is used
	/// 	without formatting.</param>
	///
	/// <param name="args">Formatting arguments.</param>
	///
	void Write(string format, params object[] args)
	{
		string str = (args.Length == 0) ? format : String.Format(format, args);
		lock (m_consoleLock)
		{
			Console.Write(str);
			if (m_logMode)
				Console.Error.Write(str);
		}
	}

	/// <summary>
	/// Writes a given character to standard output, and, if -log was specified on the command
	/// line, to standard error as well.  If -hex was specified, the character is written in this
	/// application's hexadecimal notation.
	/// </summary>
	///
	/// <param name="ch">The character to write.</param>
	///
	void WriteChar(char ch)
	{
		if (m_hexEscape > 0)
		{
			// hex mode
			WriteByteAsHex((byte) ch);
		}
		else
		{
			// non-hex mode
			lock (m_consoleLock)
			{
				Console.Write(ch);
				if (m_logMode)
					Console.Error.Write(ch);
			}
		}
	}

	/// <summary>
	/// Writes a given byte to standard output, and, if -log was specified on the command line, to
	/// standard error as well.  The byte is written in this application's hexadecimal notation.
	/// </summary>
	///
	/// <param name="b">The byte to write.</param>
	///
	void WriteByteAsHex(byte b)
	{
		lock (m_consoleLock)
		{
			// write a pair of hex bytes, preceded by the hex escape character
			Write("{0}{1:X2}", m_hexEscape, b);

			// write the byte as a character, if it's printable ASCII
			if ((b >= 0x20) && (b <= 0x7E))
				Write("`{0}'", (char) b);
		}
	}

    /// <summary>
    /// Converts a hexadecimal digit to its numeric equivalent.
    /// </summary>
    ///
    /// <param name="ch">The hexadecimal digit to convert.</param>
    ///
    /// <param name="digit">Where to store the result (0 to 15, inclusive, or -1 on error).
    ///     </param>
    ///
    /// <returns>
    /// <n>true</n> on success, <n>false</n> on failure.
    /// </returns>
    ///
	bool TryParseHexDigit(char ch, out int digit)
	{
		if ((ch >= '0') && (ch <= '9'))
			digit = ch - '0';
		else
		if ((ch >= 'A') && (ch <= 'Z'))
			digit = ch - 'A' + 10;
		else
		if ((ch >= 'a') && (ch <= 'z'))
			digit = ch - 'a' + 10;
		else
		{
			digit = -1;
			return false;
		}
		return true;
	}

    ///////////////////////////////////////////////////////////////////////////////////////////////
	// IDisposable Implementation
	//

	/// <summary>
	/// Cleans up any resources used by this object.
	/// </summary>
	///
	public void Dispose()
	{
        if (m_disposer != null)
        {
            m_disposer.Dispose();
            m_disposer = null;
        }
	}

    ///////////////////////////////////////////////////////////////////////////////////////////////
	// Nested Types
	//

	/// <summary>
	/// Indicates which mode this application is running in.
	/// </summary>
	enum AppMode
	{
		/// <summary>
		/// No mode is specified.
		/// </summary>
		None,

		/// <summary>
		/// Characters are read and written individually.
		/// </summary>
		Char,

		/// <summary>
		/// Lines of text are read and written.
		/// </summary>
		Line,

		/// <summary>
		/// Bytes are read and written individually.
		/// </summary>
		Binary
	}
}

/// <summary>
/// The <n>TraceListener</n> used by this application.
/// </summary>
///
class AppTraceListener : TraceListener
{
	public override void Write(string message)
	{
		using (Util.SetConsoleForegroundColor(ConsoleColor.Magenta))
			Console.Write(message);
	}

	public override void WriteLine(string message)
	{
		using (Util.SetConsoleForegroundColor(ConsoleColor.Magenta))
			Console.WriteLine(message);
	}
}
